{T}

入口设计 & 发布 NPM 包

学习完上节文档后,step2-7 的实现原理我们就都讲完了,在第 4 篇学习架构设计的时候,我们有提到分析工具支持两种使用模式,这一小节我们主要讲解这两种入口该如何设计,也就是 step1 的相关内容。

CLI 模式

CLI 模式即命令行模式,可以配合 npm script 来驱动,入口为可执行脚本,命令行模式在分析前会对命令行参数以及配置文件参数进行校验,对分析环境进行预处理(清理目录、创建目录),分析完成后会生成分析报告及诊断报告,清理分析环境(删除临时目录、结束进程)。

可执行脚本

第一行的 #!/usr/bin/env node 表示该文件是一个可执行脚本,相关代码在 cli/index.js 中:

typescript
#!/usr/bin/env node
const program = require('commander');                                                           // 命令行交互
const path = require('path');                                                                   // 路径操作
const fs = require('fs');                                                                       // 文件操作
const chalk = require('chalk');                                                                 // 美化输出
const { writeReport, writeDiagnosisReport } = require(path.join(__dirname, '../lib/report'));   // 报告模块
const { REPORTDEFAULTDIR, VUETEMPTSDIR } = require(path.join(__dirname, '../lib/constant'));    // 常量模块
const { mkDir, rmDir } = require(path.join(__dirname, '../lib/file'));                          // 文件工具
const codeAnalysis = require(path.join(__dirname,'../lib/index'));                              // 分析入口

program
    .command('analysis')
    .description('analysis code and echo report')
    .action(async () => {
        try{
            const configPath =path.join(process.cwd(),'./analysis.config.js');
            const isConfig =fs.existsSync(configPath);
            if(isConfig){
                let config =require(configPath);
                if(config.scanSource && Array.isArray(config.scanSource) && config.scanSource.length>0){
                    let isParamsError = false;
                    let isCodePathError = false;
                    let unExistDir = '';
                    for (let i =0; i<config.scanSource.length; i++){
                        if(!config.scanSource[i].name || !config.scanSource[i].path || !Array.isArray(config.scanSource[i].path) || config.scanSource[i].path.length ==0){
                            isParamsError = true;
                            break;
                        }
                        let innerBreak = false;
                        const tempPathArr = config.scanSource[i].path;
                        for (let j =0; j<tempPathArr.length; j++){
                            const tempPath = path.join(process.cwd(), tempPathArr[j]);
                            if(!fs.existsSync(tempPath)){
                                isCodePathError = true;
                                unExistDir = tempPathArr[j];
                                innerBreak = true;
                                break;
                            }
                        }
                        if(innerBreak)break;
                    }
                    if(!isParamsError){
                        if(!isCodePathError){
                            if(config && config.analysisTarget){
                                try{
                                    // 如果分析报告目录已经存在,则先删除目录
                                    rmDir(config.reportDir || REPORTDEFAULTDIR);
                                    // 如果temp目录已经存在,则先删除目录
                                    rmDir(VUETEMPTSDIR);
                                    // 如果需要扫描vue文件,创建temp目录
                                    if(config.isScanVue){
                                        mkDir(VUETEMPTSDIR);
                                    }
                                    // 分析代码
                                    const { report, diagnosisInfos } = await codeAnalysis(config);
                                    // 输出分析报告
                                    writeReport(config.reportDir || 'report', report);
                                    // 输出诊断报告
                                    writeDiagnosisReport(config.reportDir || 'report', diagnosisInfos);
                                    // 删除temp目录
                                    rmDir(VUETEMPTSDIR);
                                    // 代码告警/正常退出
                                    if(config.scorePlugin && config.alarmThreshold && typeof(config.alarmThreshold) ==='number' && config.alarmThreshold >0){
                                        if(report.scoreMap.score && report.scoreMap.score < config.alarmThreshold){
                                            console.log(chalk.red('\n' + '代码得分:' + report.scoreMap.score + ', 不合格'));      // 输出代码分数信息
                                            if(report.scoreMap.message.length >0){                                              // 输出代码建议信息
                                                console.log(chalk.yellow('\n' + '优化建议:'));
                                                report.scoreMap.message.forEach((element, index) => {
                                                    console.log(chalk.yellow((index+1) + '. ' + element));
                                                });
                                            }
                                            console.log(chalk.red('\n' + '=== 触发告警 ===' + '\n'));                          // 输出告警信息
                                            process.exit(1);                                                                  // 触发告警错误并结束进程
                                        }else{
                                            console.log(chalk.green('\n' + '代码得分:' + report.scoreMap.score));              // 输出代码分数信息
                                            if(report.scoreMap.message.length >0){                                            // 输出代码建议信息
                                                console.log(chalk.yellow('\n' + '优化建议:'));
                                                report.scoreMap.message.forEach((element, index) => {
                                                    console.log(chalk.yellow((index+1) + '. ' + element));
                                                });
                                            }
                                        }
                                    }else if(config.scorePlugin){
                                        console.log(chalk.green('\n' + '代码得分:' + report.scoreMap.score));          // 输出代码分数信息
                                        if(report.scoreMap.message.length >0){                                               // 输出代码建议信息
                                            console.log(chalk.yellow('\n' + '优化建议:'));
                                            report.scoreMap.message.forEach((element, index) => {
                                                console.log(chalk.yellow((index+1) + '. ' + element));
                                            });
                                        }
                                    }
                                }catch(e){
                                    // 删除temp目录
                                    rmDir(VUETEMPTSDIR);
                                    console.log(chalk.red(e.stack));        // 输出错误信息
                                    process.exit(1);                        // 错误退出进程
                                }
                            }else{
                                console.log(chalk.red('error: 配置文件中缺少必填配置项analysisTarget'));
                            }
                        }else{
                            console.log(chalk.red(`error: 配置文件中待分析文件目录${unExistDir}不存在`));
                        }
                    }else{
                        console.log(chalk.red('error: scanSource参数选项必填属性不能为空'));
                    }
                }else{
                    console.log(chalk.red('error: 配置文件中必填配置项scanSource不能为空'))
                }
            }else{
                console.log(chalk.red('error: 缺少analysis.config.js配置文件'));
            }
        }catch(e){
            console.log(chalk.red(e.stack));
        }
    })

program.parse(process.argv)

上述代码我们用到了 commander 这个开发命令行工具常用的基础包。

生成代码分析报告

代码分析结果返回的是 JS 对象,这对于使用者并不友好,我们需要更直观的表现方式,比如:

生成代码分析报告的原理很简单,首先将模版 html 复制到指定的报告输出目录,然后将分析结果写入一个 JS 文件, html 引用这个 JS 来获取数据、完成渲染,相关代码如下:

lib/file.js:

typescript
// 输出内容到JSON文件
exports.writeJsonFile = function (content, fileName) {
    try{
        fs.writeFileSync(path.join(process.cwd(),`${fileName}.json`), JSON.stringify(content), 'utf8');
    }catch(e){
        throw e;
    }
}
// 输出内容到JS文件
exports.writeJsFile = function(prc, content, fileName) {
    try{
        fs.writeFileSync(path.join(process.cwd(),`${fileName}.js`), prc+JSON.stringify(content), 'utf8');
    }catch(e){
        throw e;
    }
}

lib/constant.js:

typescript
// 代码分析报告js,json文件名字
exports.TEMPLATEDIR = 'template';
// 代码分析报告js,json文件名字
exports.REPORTFILENAME = 'index';
// 代码分析报告js,json文件名字
exports.REPORTJSPRE = 'var report=';

lib/report.js :

typescript
const { writeJsFile, writeJsonFile } = require(path.join(__dirname, './file'));                                              // 文件工具
const { TEMPLATEDIR, REPORTFILENAME, REPORTJSPRE, DIAGNOSISREPORTFILENAME } = require(path.join(__dirname, './constant'));   // 常量模块

// 生成分析报告
exports.writeReport = function (dir, content, templatePath=''){
    try{
        // 创建目录
        fs.mkdirSync(path.join(process.cwd(),`/${dir}`),0777);
        // 复制报告模版
        if(templatePath && templatePath!=''){
            fs.writeFileSync(path.join(process.cwd(), `/${dir}/${REPORTFILENAME}.html`), fs.readFileSync(path.join(process.cwd(), `${templatePath}`)));
        }else{
            fs.writeFileSync(path.join(process.cwd(), `/${dir}/${REPORTFILENAME}.html`), fs.readFileSync(path.join(__dirname, `../${TEMPLATEDIR}/${REPORTFILENAME}.html`)));
        }
        // 分析结果写入文件
        writeJsFile(REPORTJSPRE, content, `${dir}/${REPORTFILENAME}`);
        writeJsonFile(content, `${dir}/${REPORTFILENAME}`);
    }catch(e){
        throw e;
    }
}

writeReport 第三个参数用于指定自定义报告模版的路径,默认为 ' ',即使用分析工具默认的报告模版,默认报告模板文件是 template 目录下的 index.html

生成工具诊断报告

codeAnalysis 实例上的 diagnosisInfos 属性用于存放执行报错,插件报错等信息,CLI 模式在分析结束后会将诊断信息输出到 diagnosis.json 中,相关代码如下:

lib/constant.js:

typescript
// 诊断报告json文件名字
exports.DIAGNOSISREPORTFILENAME = 'diagnosis';

lib/report.js

typescript
const { writeJsonFile } = require(path.join(__dirname, './file'));                                              // 文件工具
const { DIAGNOSISREPORTFILENAME } = require(path.join(__dirname, './constant'));   // 常量模块
// 生成诊断报告
exports.writeDiagnosisReport = function (dir, content) {
    try{
        writeJsonFile(content, `${dir}/${DIAGNOSISREPORTFILENAME}`);
    }catch(e){
        throw e;
    }
}

进程结束方式

CLI 模式处理进程结束方式的代码在 cli/index.js 中的 63-91 行,如果配置文件中没有配置 alarmThreshold,分析结束会正常结束进程。如果配置了告警阈值,那么在分析结束后,会判断代码评分是否低于阈值,如果低于阈值分析程序会以 process.exit(1) 主动抛错的形式来结束进程,这种方式可以作为告警触发器,在 CI 流水线中影响后续脚本的运行,具体应用我们会在第 15 篇中介绍。

CLI 命令行模式简单便捷,但却无法拓展更多的使用场景,如果想让使用者非常方便地将代码分析能力嫁接到他们自己的工具或者服务中去,我们还需要设计一种标准输出模式。

API 模式

API 模式会导出一个封装好的分析方法 analysis,使用者只需要调用该方法即可获取代码分析结果。

API 模式相关的代码在 api/index.js 中:

typescript
const fs = require('fs');                                                       // 文件操作
const path = require('path');                                                   // 路径操作
const { VUETEMPTSDIR } = require(path.join(__dirname, '../lib/constant'));      // 常量模块
const { mkDir, rmDir } = require(path.join(__dirname, '../lib/file'));          // 文件工具
const codeAnalysis = require(path.join(__dirname, '../lib/index'));             // 分析入口

const analysis = async function(options){
    if(options){
        if(!options.scanSource || !Array.isArray(options.scanSource) || options.scanSource.length ==0){
            Promise.reject(new Error('error: scanSource参数不能为空'))
            return;
        }
        let isParamsError = false;
        let isCodePathError = false;
        let unExistDir = '';
        for (let i =0; i<options.scanSource.length; i++){
            if(!options.scanSource[i].name || !options.scanSource[i].path || !Array.isArray(options.scanSource[i].path) || options.scanSource[i].path.length ==0){
                isParamsError = true;
                break;
            }
            let innerBreak = false;
            const tempPathArr = options.scanSource[i].path;
            for (let j =0; j<tempPathArr.length; j++){
                const tempPath = path.join(process.cwd(), tempPathArr[j]);
                if(!fs.existsSync(tempPath)){
                    isCodePathError = true;
                    unExistDir = tempPathArr[j];
                    innerBreak = true;
                    break;
                }
            }
            if(innerBreak)break;
        }
        if(isParamsError){
            Promise.reject(new Error('error: scanSource参数选项必填属性不能为空'))
            return;
        }
        if(isCodePathError){
            Promise.reject(new Error(`error: 待分析文件目录${unExistDir}不存在`))
            return;
        }
        if(!options.analysisTarget){
            Promise.reject(new Error('error: analysisTarget参数不能为空'))
            return;
        }
    }else{
        Promise.reject(new Error('error: 缺少options'))
        return;
    }
    try{
        // 如果temp目录已经存在,则先删除目录
        rmDir(VUETEMPTSDIR);
        // 如果需要扫描vue文件,创建temp目录
        if(options.isScanVue){
            mkDir(VUETEMPTSDIR);
        }
        const { report, diagnosisInfos } = await codeAnalysis(options);
        // 删除temp目录
        rmDir(VUETEMPTSDIR);
        // 返回结果
        return Promise.resolve({
            report: report,
            diagnosisInfos: diagnosisInfos
        });
    }catch(e){
        return Promise.reject(e.stack);
    }
}

module.exports = analysis;

API 模式也会进行参数验证和环境处理,配置项变成了 analysis 方法的入参,API 模式不会生成代码分析报告等文件,执行成功返回分析结果,执行出错则返回错误信息。

模式优点

  1. 配置入参更灵活,使用者可以按需消费分析结果。
  2. 可启用多个分析进程并行处理多个代码分析任务。
  3. 不会生成代码分析报告等文件,对执行环境无副作用。
  4. 开发者可以快速集成代码分析能力到其它工具或服务中。

使用示例

安装 code-analysis-ts 依赖,导入 analysis 方法调用即可,下面是 code-demo 项目中 API 模式的使用示例:

typescript
const analysis = require('code-analysis-ts');
const { execSync } = require('child_process');                        // 子进程操作
const DefaultBranch = 'main';                                         // 默认分支常量
function getGitBranch() {                                             // 获取当前分支
    try{
        const branchName = execSync('git symbolic-ref --short -q HEAD', {
            encoding: 'utf8'
        }).trim();
        // console.log(branchName);
        return branchName;
    }catch(e){
        return DefaultBranch;
    }
}

async function scan() {
    try{
        const { report, diagnosisInfos } = await analysis({
            scanSource: [{                                                          // 必须,待扫描源码的配置信息
                name: 'Code-Demo',                                                                // 必填,项目名称
                path: ['src'],                                                                    // 必填,需要扫描的文件路径(基准路径为配置文件所在路径)
                packageFile: 'package.json',                                                      // 可选,package.json 文件路径配置,用于收集依赖的版本信息
                format: null,                                                                     // 可选, 文件路径格式化函数,默认为null,一般不需要配置
                httpRepo: `https://github.com/liangxin199045/code-demo/blob/${getGitBranch()}/`   // 可选,项目gitlab/github url的访问前缀,用于点击行信息跳转,不填则不跳转
            }],
            analysisTarget: 'framework',                                            // 必须,要分析的目标依赖名
            analysisPlugins: [],                                                    // 可选,自定义分析插件,默认为空数组,一般不需要配置
            blackList: ['app.localStorage.set', 'location.href'],                   // 可选,需要标记的黑名单api,默认为空数组
            browserApis: ['window','document','history','location'],                // 可选,要分析的BrowserApi,默认为空数组
            reportDir: 'docs',                                                      // 可选,生成代码分析报告的目录,默认为'report',不支持多级目录配置
            reportTitle: 'Code-Demo代码分析报告',                                    // 可选,代码分析报告标题,默认为'代码依赖分析报告'
            isScanVue: true,                                                        // 可选,是否要扫描分析vue中的ts代码,默认为false
            scorePlugin: 'default'                                                  // 可选,评分插件: Function|'default'|null, default表示运行默认插件,默认为null表示不评分
        });
        console.log(report);
    }catch(e){
        console.log(e);
    }
};

scan();

发布 npm 包

入口设置

API 模式:module 与 main 字段用于指定 npm 包入口,对于 ESM 规范,module 有更高优先级。

CLI 模式:bin 字段用于声明可执行脚本的名字和文件位置。

package.json 的配置如下:

json
{
  "name": "code-analysis-ts",
  "version": "1.3.8",
  "description": "a code dependency analysis tool for ts",
  "main": "./api/index.js",
  "module": "./api/index.js",
  "bin": {
    "ca": "./cli/index.js"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/liangxin199045/code-analysis-ts"
  },
  "author": "734099485@qq.com",
  "license": "MIT",
  "dependencies": {
    "@vue/compiler-dom": "3.0.2",
    "chalk": "^4.1.0",
    "commander": "^6.2.0",
    "glob": "^7.1.6",
    "js-md5": "^0.7.3",
    "moment": "^2.29.4",
    "ora": "^5.1.0",
    "single-line-log": "^1.1.2",
    "typescript": "4.7.4"
  },
  "engines": {
    "node": ">= 10.0.0",
    "npm": ">= 4.0.0"
  }
}

私有镜像

公司如果搭建了私有镜像服务来管理内部 NPM 包,那么可以在项目中创建一个 .npmrc 文件,然后将npm publish 推送的默认源配置改为私有镜像源,比如:

bash
registry=https://npm.iceman.io/    // 修改为私有镜像源(示例)

自动化发布

直接通过 npm publish 命令在本地发布 NPM 包可能存在以下问题:

  1. 在 Windows 或 Mac 不同的开发环境下打包,产物可能不一致;
  2. npm 包的 version版本 与 code commit 无法相互关联,不好维护。

通过 gitLab CI + Tag 的方式可以实现 NPM 自动化打包发布,Tag 用于关联代码版本与发布版本。

使用 gitLab CI 需要配置一个 .gitlab-ci.yml 文件,CI 在执行 npm publish 时需要进行权限验证,所以在 .npmrc 中还要添加有 publish 权限的 npm 账户 token 信息。

.npmrc

bash
registry=https://npm.iceman.io/                                // 私有镜像
//npm.iceman.io/:_authToken="xxxxxxxxxxx73yb6zR==========="    // token值

.gitlab-ci.yml

bash
image: node:14                        // CI执行镜像

stages:                               // CI执行阶段
  - publish

package:                              // 具体任务
  stage: publish
  only:
    - tags                            // 触发条件
  script:
    - npm publish --email=$GITLAB_USER_EMAIL --unsafe-perm    // 执行脚本

小结

这一小节我们学习了如何设计工具入口,以及如何发布 NPM 包,需要大家掌握以下知识点:

  1. CLI 模式即命令行模式,入口为可执行脚本,通常配合 CI 使用,特点是简单便捷。
  2. API 模式是一种标准输出模式,该模式可以让使用者快速集成代码分析能力到其它工具或服务中。
  3. gitLab CI + Tag的方式可以实现 NPM 自动化打包发布,Tag 用于关联代码版本与发布版本。

到此为止,我们分析工具第二阶段的相关文档就都讲完了,下一节我们讲解如何开发自定义分析插件。